/*
 * Copyright (C) 2011-2015 Lannocc Technologies
 * @%@~LICENSE~@%@
 */
package com.iovar.web.proc;

// local imports:
import com.iovar.web.*;
import com.iovar.web.bin.shell.*;
import com.iovar.web.dev.*;
import com.iovar.web.dev.trans.*;
import com.iovar.xml.*;

// java imports:
import java.io.*;
import java.net.*;
import java.util.*;
import java.util.Set;
import javax.servlet.*;
import javax.servlet.http.*;

// 3rd-party imports:

/**
 * Encapsulation of a shell session (instance variables, search path, history, etc.).
 * 
 * FIXME: this class (especially Instance) needs some serious re-work!
 *
 * @author  shawn@lannocc.com
 */
public class ShellSession extends HttpServlet
{
    static final String MASTER_SESSION = "_iodefault_";
    
    public static final String SESSIONS_PARAM = "iovar.sessions";
    public static final String SESSIONS_DEFAULT = "/proc/shell";
    
    protected void doGet (final HttpServletRequest req, final HttpServletResponse resp) throws IOException
    {
        final ServletContext context = getServletContext ();
        
        if ("/".equals (req.getPathInfo ()))
        {
            stats (context, resp);
        }
        else
        {
            final String id = getId (req);
            final Instance shell = get (context, id);

            if (shell!=null)
            {
                Log.debug ("instance found: "+shell);
                resp.setContentType ("text/xml");

                Utils.xstream.toXML (shell, resp.getOutputStream ());
            }
            else
            {
                Log.warn ("instance NOT found");
                Status.set (resp, resp.SC_NOT_FOUND, "instance `"+id+"' not found");
            }
        }
    }
    
    protected void doPut (final HttpServletRequest req, final HttpServletResponse resp) throws IOException
    {
        final ServletContext context = getServletContext ();
        final String id = getId (req);
        final Instance shell = (Instance) Utils.xstream.fromXML (req.getInputStream ());
        
        // FIXME: what about path/id mismatch?
        /*
        String root = context.getInitParameter (SESSIONS_PARAM);
        if (root==null) root = SESSIONS_DEFAULT;
        shell.path = root+"/"+id;
        */
        
        put (context, shell);
        
        Log.debug ("instance "+id+" saved");
    }
    
    protected void doDelete (final HttpServletRequest req, final HttpServletResponse resp) throws IOException
    {
        final ServletContext context = getServletContext ();
        final String id = getId (req);
        
        final Instance shell = get (context, id);
        
        if (shell!=null)
        {
            shell.delete (context);
            Log.debug ("instance "+id+" deleted");
        }
        else
        {
            Log.debug ("cannot delete; shell instance does not exist: "+id);
        }
    }
    
    String getId (final HttpServletRequest req)
    {
        String id = req.getPathInfo (); if (id==null)
        {
            throw new IllegalArgumentException ("expecting an id path");
        }
        if (id.startsWith ("/")) id = id.substring (1);
        
        id = id.trim (); if (id.length ()<1)
        {
            throw new IllegalArgumentException ("expecting an id path");
        }
        
        return id;
    }
    
    void stats (final ServletContext context, final HttpServletResponse resp) throws IOException
    {
        final PrintWriter out = resp.getWriter ();
        
        final Map<String,Instance> sessions = (Map<String,Instance>) context.getAttribute (Instance.class.getName ());
        
        if (sessions==null)
        {
            out.println ("null");
        }
        else
        {
            out.println (sessions.size ());
        }
    }
    
    
    /**
     * Store a local instance
     */
    static void put (final ServletContext context, final Instance shell) throws IOException
    {
        Map<String,Instance> shells = (Map<String,Instance>) context.getAttribute (Instance.class.getName ());
        
        if (shells==null)
        {
            if (shell.isMaster ())
            {
                shells = new HashMap<String,Instance> ();
                context.setAttribute (Instance.class.getName (), shells);
            }
            else
            {
                Log.error ("trying to store non-master local session yet no shell sessions exist locally");
                return;
            }
        }
        
        shells.put (shell.getId (), shell);
        Log.info ("stored local session: "+shell);
    }
    
    /**
     * Retrieve a local instance
     */
    static Instance get (final ServletContext context, final String id) throws IOException
    {
        Log.debug ("request for shell: "+id);
        final Map<String,Instance> shells = (Map<String,Instance>) context.getAttribute (Instance.class.getName ());
        if (shells==null)
        {
            Log.warn ("requested a local session yet no shell sessions exist locally");
            return null;
        }
            
        return shells.get (id);
    }
    
    
    
    /**
     * Return the "master" session.
     * The master session is created if it does not already exist.
     */
    public static Instance master (final ServletContext context) throws IOException
    {
        Log.debug ("getting shell master session");
        
        Instance shell = (Instance) context.getAttribute (MASTER_SESSION);
        
        if (shell==null)
        {
            Log.debug ("create master session");
            shell = new Instance (context);
            Log.info ("master session created: "+shell);
            put (context, shell);
            
            context.setAttribute (MASTER_SESSION, shell);
        }
        else
        {
            Log.info ("re-using master session: "+shell);
        }
        
        return shell;
    }
    
    /**
     * Retrieve the local user instance.
     * If the request contains the "iosession" parameter, that shell is retrieved.
     * Otherwise, the instance is created if it does not already exist.
     */
    public static Instance get (final HttpServletRequest req) throws IOException
    {
        final HttpSession session = req.getSession ();
        final ServletContext context = session.getServletContext ();
        final String user = req.getRemoteUser ();
        final String path = req.getParameter (Shell.EXT_PARAM_SESSION);
        
        if (path!=null)
        {
            Log.debug ("request for shell from request, path supplied: "+path);
            return load (context, path);
        }
        else
        {
            Log.debug ("request for shell from request, no path supplied");
            final String key = Instance.class.getName ()+":"+user;
            Instance shell = (Instance) session.getAttribute (key);
            Log.debug ("shell from user session: "+shell);
            
            // Note that user sessions can survive a servlet container restart.
            // Since any such shell would no longer be valid we check for its
            // existence here.
            final Map<String,Instance> shells = (Map<String,Instance>) context.getAttribute (Instance.class.getName ());
            if (shell!=null && (shells==null || ! shells.containsKey (shell.getId ())))
            {
                Log.debug ("removing stale user session shell: "+shell);
                shell = null;
            }

            if (shell==null)
            {
                shell = new Instance (context, master (context));
                put (context, shell);
                session.setAttribute (key, shell);
            }

            return shell;
        }
    }
    
    
    /**
     * Load a local or remote shell instance.
     */
    public static Instance load (final ServletContext context, final String path) throws IOException
    {
        Log.debug ("request to load shell at: "+path);
        final Transport handler = Transport.handler (path, context);
        
        if (handler instanceof Local || handler instanceof Resource)
        {
            String root = context.getInitParameter (SESSIONS_PARAM);
            if (root==null) root = SESSIONS_DEFAULT;
            
            if (handler.getPath ().startsWith (root+"/"))
            {
                String id = handler.getPath ().substring (root.length () + 1);
                while (id.startsWith ("/"))
                {
                    id = id.substring (1);
                }
                
                return get (context, id);
            }
            else
            {
                Log.error ("not a recognized local path for shell sessions: "+handler.getPath ());
                throw new IllegalArgumentException ("not a recognized local path for shell sessions: "+handler.getPath ());
            }
        }
        else
        {
            return (Instance) Utils.xstream.fromXML (handler.get ());
        }
    }
    
    /**
     * Delete a local or remote shell instance.
     */
    static void delete (final ServletContext context, final String path) throws IOException
    {
        Log.debug ("request to delete shell at: "+path);
        final Transport handler = Transport.handler (path, context);
        
        if (handler instanceof Local || handler instanceof Resource)
        {
            String root = context.getInitParameter (SESSIONS_PARAM);
            if (root==null) root = SESSIONS_DEFAULT;
            
            if (handler.getPath ().startsWith (root+"/"))
            {
                String id = handler.getPath ().substring (root.length () + 1);
                while (id.startsWith ("/"))
                {
                    id = id.substring (1);
                }
                
                final Map<String,Instance> shells = (Map<String,Instance>) context.getAttribute (Instance.class.getName ());
                final Instance shell = shells.remove (id);
                
                if (shell!=null)
                {
                    Log.debug ("shell instance removed: "+shell);
                    
                    final Instance parent = shell.getParent(context);
                    if (parent!=null)
                    {
                        parent.children.remove (shell.getPath ());
                    }
                }
                else
                {
                    Log.warn ("cannot delete; shell instance does not exist: "+id);
                }
            }
            else
            {
                Log.error ("not a recognized local path for shell sessions: "+handler.getPath ());
                throw new IllegalArgumentException ("not a recognized local path for shell sessions: "+handler.getPath ());
            }
        }
        else
        {
            handler.delete ();
        }
    }
    
    
    
    public static class Instance implements Serializable
    {
        public static final String ENV_INSTANCE = "INSTANCE"; // instance ID for the session
        public static final String ENV_VERSION = "IOVAR_VERSION"; // version of the IOVAR shell
        public static final String ENV_PATH = "PATH"; // user search path for executables
        public static final String ENV_USER = "USER"; // current username
        
        final String id;
        final String path;
        
        final String parentPath;
        
        final HashMap<String,String> env;
        final HashMap<String,String> locals; // locals do not propagate to children
        Integer exit;
        
        final Set<String> children;
        
        Instance (final ServletContext context)
        {
            this.id = String.valueOf (super.toString ().hashCode ());
            String sessions = context.getInitParameter (SESSIONS_PARAM);
            if (sessions==null) sessions = SESSIONS_DEFAULT;
            this.path = sessions+"/"+id;
        
            this.parentPath = null;
            
            this.env = new HashMap<String,String> ();
            
            env.put (ENV_INSTANCE, id);
            env.put (ENV_VERSION, Shell.VERSION);
            //env.put (ENV_ROOT, root);
            //env.put (ENV_WORKING_URI_BASE, pwu);
            //env.put (ENV_WORKING_DIRECTORY, pwd);
            //env.put (ENV_ROOT, "http://localhost:8080/iovar/");
            //env.put (ENV_HOME, "http://localhost:8080/iovar/");
            //env.put (ENV_PATH, "/usr/local/bin:/usr/bin:/bin:/opt/bin");
            
            this.locals = new HashMap<String,String> ();
            this.exit = null;
            
            this.children = new HashSet<String> ();
        }
        
        protected Instance (final ServletContext context, final Instance parent)
        {
            if (parent==null)
            {
                Log.error ("null parent");
                throw new NullPointerException ("null parent");
            }
            
            // FIXME: use a better id value
            this.id = String.valueOf (super.toString ().hashCode ());
            String root = context.getInitParameter (SESSIONS_PARAM);
            if (root==null) root = SESSIONS_DEFAULT;
            this.path = root+"/"+id;
            
            this.parentPath = parent.path;
            
            this.env = (HashMap<String,String>) parent.env.clone ();
            this.env.put (ENV_INSTANCE, id);
            
            this.locals = new HashMap<String,String> ();
            this.exit = parent.exit;
            
            this.children = new HashSet<String> ();
            
            // add to parent's children if parent is not master session
            parent.children.add (this.path);
            
            Log.debug ("new session ["+id+"] with parent: "+parent.id);
        }
        
        public String getId ()
        {
            return id;
        }
        
        public String getPath ()
        {
            return path;
        }
        
        public boolean isMaster ()
        {
            return parentPath==null;
        }
        
        public Instance getParent (final ServletContext context) throws IOException
        {
            if (this.parentPath==null) return null;
            
            return load (context, parentPath);
        }
        
        public String get (final String key)
        {
            Log.debug ("get ["+id+"]: "+key);
            if ("?".equals (key)) {
                Log.debug ("returning exit value: "+exit);
                return String.valueOf (exit);
            }
            else if (locals.containsKey (key)) return locals.get (key);
            else return env.get (key);
        }
        
        public String set (final String key, final String val)
        {
            Log.debug ("set ["+id+"]: "+key+"="+val);
            return env.put (key, val);
        }
        
        public String setLocal (final String key, final String val)
        {
            Log.debug ("set local ["+id+"]: "+key+"="+val);
            return locals.put (key, val);
        }
        
        public void setExit (final int exit)
        {
            Log.debug ("set exit ["+id+"]: "+exit);
            this.exit = new Integer (exit);
        }
        
        public Integer getExit ()
        {
            return exit;
        }
        
        public void clearExit ()
        {
            Log.debug ("clear exit ["+id+"]");
            this.exit = null;
        }
        
        /**
         * Non-local variables only.
         */
        public Map<String,String> getVariables ()
        {
            final Map<String,String> env =  (Map<String,String>) this.env.clone ();
            if (exit!=null) env.put ("?", exit.toString ());
            else env.remove ("?");
            return env;
        }
        
        /**
         * Local variables only.
         */
        public Map<String,String> getLocals ()
        {
            return (Map<String,String>) locals.clone ();
        }
        
        /**
         * All variables.
         */
        public Map<String,String> getEnvironment ()
        {
            final Map<String,String> env = getVariables ();
            
            for (final Map.Entry<String,String> local : locals.entrySet ())
            {
                env.put (local.getKey (), local.getValue ());
            }
            
            return env;
        }
        
        public String export (final ServletContext context, final String key, final String val) throws IOException
        {
            return export (context, key, val, false);
        }
        
        public String export (final ServletContext context, final String key, final String val, final boolean master) throws IOException
        {
            if (master)
            {
                return export (-2, context, key, val);
            }
            else
            {
                // FIXME: this probably shouldn't go all the way up
                return export (-1, context, key, val);
            }
        }
        
        /**
         * @param   count   How far up to go. Use -1 to go all the way to the user shell.
         *                  Use -2 to go up to the master session.
         */
        public String export (final int count, final ServletContext context, final String key, final String val) throws IOException
        {
            final Instance parent = getParent (context);
            
            if (count>0)
            {
                if (parent!=null) parent.export (count-1, context, key, val);
            }
            else if (count<0)
            {
                if (parent!=null) parent.export (count, context, key, val);
            }
            
            if (count==-2 || ! isMaster ())
            {
                return set (key, val);
            }
            else
            {
                return null;
            }
        }
        
        public List<String> getPathList ()
        {
            final String path = get (ENV_PATH);
            if (path==null) return null;
            
            final List<String> list = new ArrayList<String> ();
            
            // using comma as separator because colon is so prevalent in URIs
            for (final StringTokenizer st = new StringTokenizer (path, ","); st.hasMoreTokens (); )
            {
                list.add (st.nextToken ());
            }
            
            return list;
        }
        
        /*
        public void saveTo (final String path, final ServletContext context) throws IOException
        {
            Log.info ("put "+this+" at "+path+":");
            
            final ByteArrayOutputStream bytes = new ByteArrayOutputStream ();
            Utils.xstream.toXML (this, bytes);
            / *
            final ObjectOutputStream out = new ObjectOutputStream (bytes); try
            {
                out.writeObject (this);
            }
            finally
            {
                out.close ();
            }
            * /
            
            Transport.handler (path, context).put (new ByteArrayInputStream (bytes.toByteArray ()));
        }
        */
        
        public InputStream save (final ServletContext context) throws IOException
        {
            Transport handler = Transport.handler (path, context);

            if (handler instanceof Local || handler instanceof Resource)
            {
                // FIXME... evaluate completeness
                put (context, this);
                /*
                Map<String,Instance> sessions = (Map<String,Instance>) context.getAttribute (Instance.class.getName ());
                if (sessions==null)
                {
                    sessions = new HashMap<String,Instance> ();
                    context.setAttribute (Instance.class.getName (), sessions);
                }

                sessions.put (handler.getPath (), this);
                */
                return null;
            }
            else
            {
                final ByteArrayOutputStream bytes = new ByteArrayOutputStream ();
                Utils.xstream.toXML (this, bytes);
                return handler.put (new ByteArrayInputStream (bytes.toByteArray ()));
            }
        }
        
        /**
         * FIXME: a quick hack; not sure this should even exist (SECURITY)
         */
        public void saveUp (final ServletContext context) throws IOException
        {
            saveUp (-1, context);
        }
        
        
        /**
         * FIXME: a quick hack; not sure this should even exist (SECURITY)
         * @param   count   How far up to go. Use -1 to go all the way.
         */
        public void saveUp (final int count, final ServletContext context) throws IOException
        {
            save (context);
            
            if (count!=0)
            {
                final Instance parent = getParent (context);
                if (parent!=null) parent.saveUp (count-1, context);
            }
        }
        
        /**
         * Create and store a child session containing the environment of this session.
         */
        public Instance fork (final ServletContext context) throws IOException
        {
            final Instance shell = new Instance (context, this);
            put (context, shell);
            return shell;
        }
        
        /**
         * FIXME (not implemented)
         */
        public Instance merge (Instance other)
        {
            // FIXME
            throw new RuntimeException ("merging not yet implemented");
        }
        
        /**
         * Delete this shell and its children.
         */
        public void delete (final ServletContext context) throws IOException
        {
            for (final String child : children)
            {
                ShellSession.delete (context, child);
            }
            
            ShellSession.delete (context, path);
        }
        
        public String toString ()
        {
            return super.toString () + "{ "+id+" }";
        }
    }
    
    public static class Listener implements HttpSessionListener
    {
        public void sessionCreated (final HttpSessionEvent event)
        {
            
        }
        
        public void sessionDestroyed (final HttpSessionEvent event)
        {
            final HttpSession session = event.getSession ();
            final ServletContext context = session.getServletContext ();
            
            for (final Enumeration<String> keys = session.getAttributeNames (); keys.hasMoreElements (); )
            {
                final String key = keys.nextElement ();
                if (key.startsWith (Instance.class.getName ()+":"))
                {
                    final String path = (String) session.getAttribute (key);
                    
                    try
                    {
                        delete (context, path);
                    }
                    catch (final IOException e)
                    {
                        Log.error ("exception while deleting: "+path, e);
                    }
                    
                    session.removeAttribute (key);
                }
            }
        }
    }
}
